커널 소스코드를 이해하기 위한 x86 사전지식

리눅스2025. 01. 24.
게시일
Jan 25, 2025
카테고리
시리즈
계속 업데이트중입니다.
최대한 메뉴얼을 찾아서 레퍼런스를 같이 넣으려고 하지만, 그럼에도 틀린내용이 많이 있을 것 같습니다. 이 점 양해 부탁드리고, 틀린부분에 대해 지적해주신다면 너무나도 감사하겠습니다.

Reference

상향성 호환

Intel Manual Volume 1. 2
현재 출시되는 x86 CPU는 모두 64 bit CPU이다. x86 CPU의 시작은 78년에 출시된 8086이다. 8086 CPU는 16비트(20비트)로 동작한다. 80286이 출시되면서 memory protection이 처음 도입되고, 1985년 최초의 인텔 32비트 CPU인 80386 CPU가 출시되며 기존 16비트와 하휘호환성을 유지한 32비트 IA-32 아키텍처가 정립되었다.
2003년 인텔의 경쟁사 AMD는 기존 x86과 호환되면서 64비트로 확장한 애슬론 64 CPU를 2003년에 출시한다. 이를 AMD64 아키텍처라고 한다. 그동안 인텔은 x86과는 완전히 다른 새로운 아키텍쳐인 IA-64(아이태니엄)를 개발하지만 시장에서 실패한다. 결국 인텔은 IA-64를 포기하고 AMD64 아키텍처와 호환되는 CPU를 출시하는데, 이를 Intel 64라고 부른다. 즉 AMD64와 Intel 64, x86-64 모두 같은 ISA이다. x86은 이 끈질긴 상향성 호환 덕분에 이 긴 세월동안 살아남을 수 있었다.
상향성 호환은 x86 CPU의 가장 큰 특징이다. x86-64의 아키텍쳐는 호환성을 유지하면서 발전해왔기 때문에 처음부터 32비트, 64비트로 개발을 시작한 MIPS나 ARM 프로세서에 비해 복잡한 구조가 많다. 따라서 x86 아키텍처를 올바르게 공부하려면 아래 나오는 Real Mode부터 차근히 알아보는 것이 좋은 것 같다.

실행 모드 (Modes of operation)

Intel Manual Volume 1. 3.1

Real-Address Mode (A.K.A Real Mode)

CPU에 처음 전원이 인가되면 실행되는 모드이다. 마치 8086 CPU처럼 동작하는 모드이다. 따라서 레지스터는 16비트이고, 주소는 세그먼트와 오프셋의 조합으로 20비트 주소선을 사용한다. 주소지정은 0x00000000~0x000FFFFF 까지 가능하다.
notion image
8086의 Register 구성이다. 64비트 cpu에서도 real mode에서는 이 방식으로 구동된다고 보면 된다.

범용레지스터

AX~DX는 범용레지스터이다. 32비트에서는 EAX~EDX로 확장된다.

Stack

BP, SP는 스택에 사용되는 레지스터이다.
BP는 스택의 BASE를, SP는 스택의 TOP을 가르친다.
x86에서는 스택이 Grow Down된다.즉 높은 주소에서 낮은 주소로 자란다는 것이다.

Index

SI, DI는 인덱싱에 사용되는 인덱스 레지스터이다.

Segment

8086에는 세그먼트 레지스터들이 있다. 다만 세그먼트 보호 기능은 없다. Real-Mode에서 세그먼트 레지스터는 그저 주소를 가르키기 위한 수단으로서 사용될 뿐이다. 32비트 protected mode에서는 보호 기능이 탑재되면서 Segment Register에 권한 관련 비트가 (RPL, CPL) 들어가게 된다. 32비트에서는 segment register를 보호모드와 권한(ring 0-4)을 설정하기 위해서도 사용하는데, 일단 Real-Mode에서는 그저 16비트의 부족한 주소선을 떼우기 위해, 그리고 프로그래머가 메모리 영역을 코드와 스택과 데이터 영역을 나누어 사용하기 편하게 하기 위해 Segment Register가 필요하다고 생각하면 된다.
notion image
리얼모드에서 실제 물리주소는 위와 같이 산출된다. 세그먼트 레지스터를 4번 shift하고, 그 값을 오퍼랜드와 더해서 실제 물리주소를 참조한다.
notion image
세그먼트 레지스터는 하나가 아니라 CS, DS, SS 세가지가 있다.
CS는 코드주소의 Segmentation을 가리키고
SS는 스택영역의 Segmentation을 가리키고
DS는 데이터영역의 Segmentation을 가리킨다.
즉 SP의 주소를 접근할 때는 SP의 값을 OFFSET으로, SS를 Segment로 처리하여 물리주소를 참조한다.
SP*16 + SS = 스택이 실제로 저장된 위치
IP*16 + CP = 코드가 실제로 저장된 위치
범용레지스터*16 + DS = 데이터가 실제로 저장된 위치
이처럼 x86에서는 코드영역, 데이터영역, 스택영역을 세그먼트 별로 따로따로 관리한다. 이 세그먼트 레지스터들을 이용하면 C언어의 메모리 구조를 곧바로 구현할 수 있다.
notion image
< C에서의 메모리 구조 >

인터럽트와 IVT

인터럽트 벡터 테이블, 1KiB의 공간으로, 물리주소의 0x00000000~0x000003FF의 영역에 할당되어있다.

NMI 인터럽트

Timer 인터럽트가 대표적. 커널을 주기적으로 동작시키기 위해서는 이 인터럽트는 어떠한 상황이 있더라도 반드시 실행되어야 한다.

Protected Mode

32 비트 보호모드이다. 32비트 OS에서는 부팅이 완전히 끝나게 되면 이 모드로 동작하게 된다. 만약 64비트 OS라면 아래의 IA-32e로 진입한다.
보호모드로 들어가면 (volume 3. chapter 5 protection 참고) 유저권한일 때 (ring1~3)에서는 몇가지 명령어를 사용하는 것이 불가능해진다. 대표적으로 CR 레지스터를 조작하지 못한다.

세그멘테이션

Real-Mode에서는 세그멘테이션을 사용한다. 32비트 protected mode에는 페이징 기능이 추가된다. 그리고 뜬금없게도 이부분이 x86의 권한 설정, 비교, 보호 기능과 밀접하게 연관되어있다.
Real-Mode에서는 페이징을 끌 경우, 세그멘테이션을 통해 나온 주소로 직접 물리주소에 접근하게 된다. 페이징은 CR0의 PG비트를 설정하면 된다. 그런데 사실 페이징을 켠다 하더라도 근본적으로는 세그멘테이션도 사용한다. 아래에서 설명.
Protected Mode 세그멘테이션에서 새로 추가된 기능은
1. 권한설정
2. Base와 Limit의 개념
3. 디스크럽터와 디스크럽터 테이블
그리고 Base와 Limit을 세그먼트 내부에 저장
... DT와 GDT에 대한 설명 ...
다른 CPU의 경우 보통 권한설정을 상태 레지스터에 저장한다. ex) ARM의 PSR 레지스터
그러나 x86의 경우 본래 Segmentation의 보호를 위한 기능에 불가했던 "ring"의 개념을 대폭 확대한 역사 덕분에, 이런 복잡한 구조를 가지게 된 것이다. (x86을 공부할 때는, x86이 마이크로프로세서 중에는 최초로 페이징과 세그멘테이션, 보호 등의 개념을 지원했다는 역사적 사실을 항상 염두에 두어야 함)

선형주소와 페이징

페이징을 사용하는데도 여전히 세그멘테이션을 사용함.
가상주소에 대해 세그멘테이션을 설정하고, 변환된 주소(이제는 물리주소가 아니라 선형주소라고 부름)를 다시 페이지 테이블을 참고해 최종적으로 물리주소로 반환함.
즉 x86은 원칙적으로 세그멘테이션과 페이징을 함께 사용하는 시스템임.
그러나 리눅스 커널의 경우 그냥 세그멘테이션을 주소공간 전체에 할당해버림. (커널의 /arc/x86에서 GDT 코드 관련 확인)
사실상 CS의 권한 기능만을 사용하는 셈
리눅스 커널은 각 프로세스마다 커널부분의 영역이 mapped되어있음. 즉 모든 프로세스가 가상주소 상위 1GB 영역에 커널 부분을 가리키도록 페이지 테이블을 만든것임. 즉 커널이 각 프로세스를 처음 생성할 때, 페이지테이블의 상위 1GB 영역의 경우 커널 영역을 가리키도록 채워넣는다는 것임. 그리고 커널영역은 사용자로부터 보호되어 읽고 쓰는것을 방지함. 이는 page table entry에는 u/s 비트를 이용함.
어차피 사용자는 읽고 쓰지도 못하는 커널영역을 뭐하려 사용자 process에게 할당하는 이유는, process에서 kernel코드로 넘어갔을 때 kernel 코드를 위한 페이지 테이블을 새로 로드할 필요가 없기 때문이다. 즉 kernel 코드는 process의 context를 그대로 사용한다.
여담으로 어떤 블로그에서는 커널영역의 보호를 GDT의 세그멘테이션 영역에서 커널부분을 보호하기 때문이라고 설명했다. 아마 유저영역이랑 커널영역의 GDT에서 Base와 Limit을 따로 설정한다는 뭐 그런 원리인 것 같다. 하지만 위 리눅스의 GDT 설정 코드를 봐도 알 수 있듯 실제 리눅스 커널에서는 각각의 세그멘테이션(data, code 영역 등)을 0x00000000~0xffffffff까지 할당하기 때문에 세그멘테이션을 나누는 의미가 없다. 아마 커널 영역을 유동적으로 설정하고 보호한다는 측면에서 봤을 때, PTE에서 보호되도록 설정하는 것이 아마 공학적으로 더 나은 디자인이기 때문에 리눅스에서 이런 전략을 취한듯(?)

32-bit 페이징

notion image
단일 페이징 테이블은 용량이 너무 크기 때문에, x86에서는 다중 페이지테이블을 사용한다.
  • 왜 다중 페이지 테이블이 필요할까?단순 계산으로 32비트 가상주소공간에서 한 페이지 크기가 4kb라고 가정했을 때, 한 프로세스당 2^20개의 PTE가 필요하다. PTE가 4byte라고 가정하면, 한 프로세스당 4MB를 할당해야 한다. 여기까지는 괜찮은것 같다. 그러나 64비트로 넘어가면 이야기가 달라진다. 일반적으로 64비트에서도 페이지 크기는 4kb이다. (내부 단편화를 줄이면서 페이징의 이점을 살리려면 4kb가 가장 적절하다고 한다.) 따라서 한 프로세스당 2^52개의 PTE가 필요하다. 한 PTE의 크기가 8byte라고 가정했을 때, 페이지테이블에 무려 32페타바이트의 용량이 필요하다. 일반적인 PC에서는 디스크를 전부 사용하더라도 한 프로세스의 페이지테이블도 유지할 수 없다. 따라서 x64에서는 반드시 다중 페이지테이블을 사용해야한다. 물론 다중 페이지테이블을 사용한다고 하더라도 용량이 줄어드는 것이 이니라, 오히려 늘어나기만 하는 것 아니냐는 오해를 할 수 있다. (레벨을 깊게 팔 수록 상위레벨의 페이지테이블들 위한 추가적인 자료구조가 필요하기 때문이다.) 그러나 핵심적인 아이디어는 페이지 테이블 또한 demand-paging의 대상이 될 수 있다는 것이다. 즉, 필요할 때만 하위 페이지테이블을 할당하도록 하면 된다. 다만 가장 상위 페이지 테이블에 경우 물리주소에 고정된 위치에 항상 상주해있어야 한다. 애초에 가상주소를 물리주소로 바꾸려고 페이지테이블이 필요한 것인데, 페이지 테이블이 전부 가상주소로 들어가 있으면 주소 변환을 하려고 해도 참조할 수가 없다. 그러므로 가장 상위, 즉 맨 첫번째로 접근하는 페이지 테이블은 고정된 물리 주소에 상주해있어야 하는 것이다. x86에서는 이 고정된 물리주소의 위치를 cr3라는 레지스터에 저장함으로서 동작한다.
x86의 다중페이징에는 여러가지 모드가 있지만, 일반적으로 4단계 페이징을 흔히 사용한다.
notion image
4단계 페이징의 경우 그림과 같다. 살펴볼 수 있듯, 가상주소는 64비트 전체를 사용하는 것이 아닌 47비트까지만 사용한다. 이정도만 해도 64테라바이트의 용량을 커버할 수 있다. 5단계 페이징에서는 64비트 전체를 사용할 수 있으며, 이 경우 4엑사바이트의 주소를 지정할 수 있다. 아마 내가 죽기 전에 가상주소 64비트를 전부 사용하는 x86머신을 볼 수 있을지는 모르겠다. (이 글을 쓰는 기준 전세계 데이터센터의 데이터를 전부 합쳐봐야 100엑사바이트가 채 안된다고 한다.)
ISA에서 지원을 하지 않고, ISA 독립적으로 다중페이징을 할 수도 있다. 다만 엄청난 시간 오버헤드가 발생할 것이다. x86의 경우, tlb의 내용은 os에게 투명하며(투명하다는 건 os가 tlb 교체 알고리즘을 따로 작성하지 않아도 cpu가 알아서 tlb 엔트리를 교체한다는 의미이다. 물론 tlb를 비우는 등 os에서 직접 조작을 해주는 방법이 없는 것은 아니며, context switch 이후에는 반드시 os가 직접 tlb를 flushing 시켜야 한다.)
프로세스가 context-switch를 할 때 운영체제에서 CR3 레지스터에 채워넣는다. CR3에는 PML4 페이지테이블의 시작주소가 저장되어있다. 다중 페이지테이블도 단일 페이지테이블과 마찬가지로 프로세스마다 고유해야한다. 각 PML4 엔트리들은 페이지 디렉토리 포인터 테이블의 시작주소를 가르킨다. 페이지 테이블 그 자체도 demand paging이 가능하다고 했다. PML4 엔트리를 제외하고는 모든 페이지테이블은 실행중에 동적으로 생성된다.
따라서 일반적인 C프로그램의 실행환경의 경우,
상위 스택영역에 1개의 Directory ptr, Directory, page table이 각각 할당된다. 그리고 하위 BSS, heap, 코드 영역에 또 1개의 Directory ptr, Directory, page table이 각각 할당되어 총 6개 + 기본 plm4 까지 총 7개의 실제로 메모리에 올라가며, 각각의 테이블에서 entry의 개수는 512개이며, 한 프로세스가 올라갈 때 메모리에 할당되는 초기 페이지테이블들의 용량은 고작 수 kb에 불과하다. 만약 프로세스가 주소공간을 점점 더 먹게 된다면, OS는 페이지 테이블을 동적으로 더 할당하게 될 것이다.

Inturrupt와 IDT에 대한 설명

PDT와 매우 유사함. IDT는 인터럽트 핸들러의 실행위치를 지정한다.

System management mode(SMM)

하드웨어 의존적인 작업을 할 때 사용되는 것 같은데 자세히는 모른다.

IA-32e

Intel 64 아키텍처의 기능을 사용하기 위해서는 IA-32e 모드로 진입해야 한다. IA-32e라는 용어가 나온다면, 64비트 모드라고 알고 있으면 된다. 물론 IA-32e또한 하휘호환성을 위해 32비트 모드(compatible mode)를 탑재하고 있다. 이 덕분에 32비트용 프로그램을 실행할 때 IA-32e에서 IA-32로 왔다갔다 할 필요가 없는 것이다.

flat model

notion image
앞서 32비트에서는 세그멘테이션과 페이징을 함께 사용하는 것을 보였다. 그러나 64비트에서는 더이상 CS,SS,DS를 사용해서 세그먼트를 나누지 않는다.그냥 데이터나, 코드나, 스택영역 모두 하나의 선형 가상공간 안에서 사용된다. 이를 Flat Model이라고 한다.
(근데 위에서 설명했다시피 32비트 커널에서도 세그먼트를 BASE를 0으로, LIMIT을 전체로 설정한다. 즉 코드영역이나 스택영역이나 데이터 영역이나 전부 겹쳐서 이미 32비트 시절부터 소프트웨어적으로는 flat model처럼 쓰던거나 다름 없다.)

I/O Map

x86이라기 보다는 IBM-compatible한 PC들의 속성이다. 버스 구조를 정의하는데 이 부분은 ISA만 살펴볼 게 아니라 PCI, AMD와 INTEL의 각 칩셋을 알아봐야 할 것 같다.

부팅과정

이 부분은 어디 메뉴얼 보고 참고한것도 아니고 인터넷 긁어보면서 짜집기한터라 틀린 내용 있을 수 있음.

초기상태

Manual Volume 3. Chapter 10.9.2 참고
x86는 Reset시 Real Mode로 동작하고, 최초로 0xFFFFFFF0h 위치의 코드를 실행한다.
  • 좀 더 자세한 설명조금 이상하다. REAL mode는 20비트 주소이기 떄문에 0x000FFFFFh가 접근 가능한 최대 주소인데, 0xFFFFFFF0h에 접근이 가능하다니? 이 부분을 이해하기 위해서는 일단 x86이 전원이 들어왔을 때 최초로 레지스터가 세팅된 값을 살펴봐야 한다. 초기 CS에는 0xF000가 저장되어있다. 그렇다면 위 세그멘테이션에서 살펴본 대로 CS의 BASE는 0x000F000부터 실행을 시작한다고 짐작할 수 있다. 그런데, CS는 부팅 초기에만 조금 다르게 동작한다는 사실! 부팅 초기시에는 CS는 0xFFFF0000부터 동작하기 시작한다. 그렇기에 하위 1MiB공간 밖의 주소임에도 접근이 가능하다. (자세한 것은 위 메뉴얼의 챕터10 에서 CS Hidden Part 참고) 리셋 단계에서 EIP에는 0xFFF0가 저장된다. 따라서 최초로 CPU가 실행하는 위치는 0xFFFFFFF0가 된다. 물론 CS가 이렇게 동작하는 것은 전원공급 직후에만 있는 특수한 상황이고, 이후 cs의 값을 바꾼다면 CS는 우리가 아는대로 동작대로 동작한다. cpu리셋시 동작에 관한 설명은 메뉴얼 볼륨3, 챕터 10.9단락을 참고하면 자세히 볼 수 있다.
notion image
하드웨어는 위와 같이 0xFFFF0000h에서 0xFFFFFFFF까지를 EPROM에 매핑한다. 주소가 실제로 ROM에 매핑되어있는 것이다. 이제 최초로 실행하는 명령어인 0xFFFFFFF0h에는 보통 최초의 펌웨어(바이오스, UEFI)를 실행하는 코드로 넘어간다. 위 사진만 보면 BIOS는 64k밖에 안할 것 같이 생겼지만, 사실 하드웨어 제조사들은 1MB 공간에 추가적으로 공간을 더 할당했다.

BIOS

CPU가 리셋되면, 리셋 벡터(위 0xffffffff0)에서 BIOS코드로 점프한다. 이후 HW를 점검하고, MBR 찾아서 부트로더를 메모리에 적재한다. BIOS는 부트로더를 16비트 real mode로 진입시킨다. 64비트 진입은 부트로더 or 커널 소스가 알아서 해야하는 걸로 알고있다.

메모리 MAP

하위 1MB 메모리 map의 일부 공간은 I/O 장치에 direct mapped 되어있음
notion image
왜 bios는 64KiB보다 더 큰 사이즈를 가졌는가?

UEFI

전원 들어온 이후 HW 점검하고, GDT 찾아서 부트로더 올림. 만약 BIOS 호환성 모드를 지원하는 경우
64비트 protected mode로 진입
다만 identity-mapped 됨(가상주소 = 물리주소), 아마 페이지 테이블을 물리주소에 대응되게끔 했는듯(?) 그래야 커널 코드를 아마 올리기 수월할 것이다.
 
 
 

 
notion image
Minseok Kim / Semteul
블로그 겸 노트에 오신 것을 환영합니다.
 
블로그 홈
🚀
Minseok’s 노트
 
태그 다른글